Skip to content

feat(workflow): identity-safe assign_agent action (Slice 1) - #6060

Open
mfethe1 wants to merge 2 commits into
block:mainfrom
mfethe1:feat/workflow-assign-agent
Open

feat(workflow): identity-safe assign_agent action (Slice 1)#6060
mfethe1 wants to merge 2 commits into
block:mainfrom
mfethe1:feat/workflow-assign-agent

Conversation

@mfethe1

@mfethe1 mfethe1 commented Aug 16, 2026

Copy link
Copy Markdown

Summary

Adds an identity-safe assign_agent workflow action so agent dispatch binds to an immutable hex pubkey instead of a reverse-parsed @Name mention. Two structural failure modes of the mention path go away: (a) two channel members sharing a display name silently wake no one; (b) a rename silently rewrites the target.

  • Head: 6e7044ac62d81d98aa7560dccfef3b093bee879d
  • Merge base: d8281b9c93395f15d55091b131bb2747a0a3da8a (one commit behind origin/main @ f956e6fe, no conflict)
  • Originating conversation: Buzz channel 4108b496-0efb-4fc6-85e3-6c88defb467c (autonomous-collaboration Slice 1)

Review status

There is an open CHANGES_REQUESTED against this exact head (review 4946833713, @themiguelamador) that has not yet been answered. The "Known gaps" section below folds in its findings so the diff and the body agree; a prepared fix exists at Complear:review/pr-6060-fix (626849e7). Do not read the sections below as review-clean.

What ships

  • Schema (crates/buzz-workflow/src/schema.rs) — new ActionDef::AssignAgent { agent_pubkey, text, channel?, task_id? }, plus a new validate_action hook called from WorkflowDef::validate for every step. Definition-time validation accepts either a static 64-char lowercase hex agent_pubkey or a single {{...}} template placeholder (e.g. {{trigger.author}}); mixed literal+template strings such as prefix-{{trigger.author}} are rejected so a stray name cannot smuggle an identity in. text must be non-empty. channel and task_id must parse as UUIDs when set.
  • Action sink (crates/buzz-workflow/src/action_sink.rs) — extends the ActionSink trait with assign_agent(community_id, channel_id, text, author_pubkey, agent_pubkey, task_id) and adds ActionSinkError::AssigneeNotMember. Deliberately no default implementation: RelayActionSink is the only implementor in the tree (git grep -n "impl ActionSink" -- '*.rs' returns exactly one hit), so a missing impl is a compile error rather than a runtime unimplemented!.
  • Executor (crates/buzz-workflow/src/executor.rs) — new AssignAgent arms in resolve_step_templates and dispatch_action. The resolved agent_pubkey is re-validated as 64-char lowercase hex before dispatch, so a misspelled template variable (which resolve_template passes through as literal {{...}}) fails the run loudly instead of misrouting.
  • Relay sink (crates/buzz-relay/src/workflow_sink.rs)RelayActionSink::assign_agent resolves the tenant, validates text and channel UUID, rejects archived channels, checks the workflow owner's channel access, and membership-checks the assignee, returning AssigneeNotMember fail-closed if they are not in the channel. It then signs a kind:9 (KIND_STREAM_MESSAGE) event carrying an owner attribution p tag, an assignee p tag (deduplicated when owner == assignee, so the tag count is one or two), an h channel tag, buzz:workflow, and an optional task correlation tag. The message text is never scanned for @Name.

Known gaps at this head

Carried from the open review; listed here so the body matches the diff rather than the intent.

  1. Owner attribution can wake a second agent. ACP wakes on any p tag matching an agent's pubkey (crates/buzz-acp/src/filter.rs:390). Emitting p(owner) alongside p(assignee) therefore wakes a managed-agent owner too, which qualifies the single-wake premise. This is inherited from send_message, which already emits the same attribution tag; the suggested fix is to move owner attribution to the relay-trusted actor tag (already recognized at crates/buzz-relay/src/handlers/ingest.rs:889). Whether to change send_message at the same time is an open call.
  2. Templated channel / task_id are resolved but cannot be saved. resolve_step_templates templates both fields, while validate_action requires them to parse as UUIDs — which no {{...}} string does. The executor test assign_agent_resolves_text_and_task_id_templates demonstrates the templated path only because it constructs a Step directly and never calls WorkflowDef::validate. Either the validator should accept a single template placeholder (with a resolved-value UUID re-check in the executor) or the templating should be dropped from those two fields. This also diverges from send_message, which still accepts a templated channel.
  3. Identity shape checks trim but store untrimmed. is_lowercase_hex_pubkey and is_single_template both .trim() for the check while validate_action never writes the normalized value back, so " <64-hex>" and " {{trigger.author}}" save cleanly and fail at dispatch. is_single_template also accepts the malformed {{{x}}} form.
  4. The public sink boundary does not validate task_id. The trait doc claims "the executor performs shape validation before calling", but the executor re-validates only agent_pubkey. A direct ActionSink::assign_agent caller can emit a non-UUID task tag, and a whitespace-only task_id is silently dropped rather than rejected.
  5. Error classification is lossy. The pre-existing From<ActionSinkError> for WorkflowError maps every variant to WorkflowError::WebhookError, so AssigneeNotMember and DB failures both persist as webhook_failed. Untouched by this PR, but the new variant is the first one for which the collapse is clearly wrong.
  6. Shared diagnostics carry the wrong action name. resolve_send_message_channel hardcodes "SendMessage:" in all four of its error strings, which AssignAgent now shares.
  7. Assignment text is copied into an info log ("AssignAgent → {channel}: {text}"), mirroring send_message. Task content may carry incident or customer detail.

Registry mirrors not yet updated

git grep -ln "assign_agent\|AssignAgent" at this head returns only the four changed files. Three mirrors of the action enumeration are therefore stale:

Mirror State
ARCHITECTURE.md:534 still **7 action types:** with a 7-row table, byte-identical to origin/main
crates/buzz-workflow/src/schema.rs:456 parse_all_action_types still asserts steps.len() == 7; passes, so the gap is silent
desktop/src/features/workflows/ui/workflowFormTypes.ts:12 ACTION_TYPES is a 7-element closed list; line 256 rejects unknown actions with Unsupported action type "…" — use the YAML editor, so an assign_agent workflow bounces the whole definition out of the form editor

The desktop form-editor gap degrades gracefully (YAML editor still works) and can reasonably be a follow-up; the doc and test updates should land here.

Non-goals for this slice

Task leases/claims, reviewer-independence enforcement, exact-head binding, availability-aware reassignment, hop/budget/terminal-state protocol, workflow approval suspend/resume (WF-08). No run-event emission or #run filtering — Nostr generic-tag filters are single-letter and #run is not valid; run history will be wired to the existing DB-backed GET /workflows/{workflow_id}/runs endpoint in a follow-up. No change to PR #5983 lanes. No mobile or CLI surface.

Diff shape

File +lines Notes
crates/buzz-workflow/src/schema.rs +287 variant + validate_action + 3 shape predicates + 11 tests
crates/buzz-workflow/src/action_sink.rs +39 trait method + AssigneeNotMember variant
crates/buzz-workflow/src/executor.rs +172 template arm + dispatch arm + 3 tests
crates/buzz-relay/src/workflow_sink.rs +386 sink impl + 2 Postgres-gated integration tests

Four files, 884 insertions, 0 deletions.

Verification

Repo CI has not run on this head. All three workflows (CI, Docker image, Desktop Release Candidate) sit at action_required pending maintainer approval; DCO Check is the only executed check and it passes. Everything below is a local result on 6e7044ac and needs CI confirmation.

  • cargo fmt -p buzz-workflow -p buzz-relay -- --check — clean
  • cargo clippy -p buzz-workflow -p buzz-relay --tests --all-features -- -D warnings — clean
  • cargo test -p buzz-workflow --lib — 169 passed, 0 failed, 2 ignored
  • cargo test -p buzz-relay --lib workflow_sink — 17 passed, 0 failed, 3 ignored (2 new + 1 pre-existing Postgres-gated)
  • Postgres-gated tests (cargo test -p buzz-relay --lib workflow_sink -- --ignored) — authored, not executed; no accessible local Postgres. Covers the duplicate-name repro (two Winnie members, only the selected pubkey is p-tagged) and the non-member fail-closed path.
  • Live-relay end-to-end — not run. Reviewer step: create a workflow with an assign_agent step targeting one of two same-name members, trigger it, confirm exactly one wake.

Two buzz-relay --lib failures (api::git::policy::tests::bash_hmac_matches_rust_hmac, api::admin::tests::feedback_attachment_rejects_unknown_feedback) reproduce on the clean d8281b9c base and are unrelated to this change.

Replaces prose-inferred agent dispatch (via `send_message` reverse-parsing
of `@Name` mentions) with a pubkey-addressed workflow action. Two failure
modes of the mention path are structural: (a) two channel members sharing
a display name make the mention ambiguous and wake no one; (b) a rename
silently rewrites the target. `assign_agent` binds dispatch to the
target's hex pubkey and membership-checks at emit time.

Contract per Airy's Slice-1 review:
- Singular `agent_pubkey` — group fan-out is a separate explicit step.
- The relay sink emits exactly two `p` tags: workflow owner (attribution)
  and `agent_pubkey` (wake). Text is NEVER reverse-parsed for `@Name`.
- Fail-closed: `AssigneeNotMember` if the agent is not a channel member.
- Schema accepts a static 64-hex pubkey OR a single `{{...}}` template
  placeholder (e.g. `{{trigger.author}}`); mixed literal+template strings
  are rejected so a stray name cannot smuggle an identity in. The resolved
  value is re-validated as 64-char lowercase hex at dispatch time.
- No default `ActionSink` impls; the new `assign_agent` method is a hard
  compile-time obligation on any implementor.

Tests: 11 schema tests (parse/round-trip/hex-shape rejects), 3 executor
template-resolution tests, 2 postgres-gated relay-sink integration tests
covering the duplicate-name repro (two "Winnie" members, only the
selected pubkey wakes) and the non-member fail-closed path.

Non-goals for this slice (per Airy): task leases, reviewer-independence,
approval suspend/resume, run-event emission. Run history is addressed
separately by wiring the CLI to the existing DB-backed
`GET /workflows/{id}/runs` endpoint in a follow-up PR.

Signed-off-by: Michael Feth <michael@jira-flow.com>
@mfethe1
mfethe1 requested a review from a team as a code owner August 16, 2026 16:42

@themiguelamador themiguelamador left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. The identity-safe action is the right direction, but the submitted head does not yet preserve its advertised single-assignee and template contracts.

Findings:

  • P1 — owner attribution can wake a second agent. ACP treats every p tag as a wake target. Emitting p(owner) plus p(assignee) therefore wakes the workflow owner too whenever the owner is a managed agent. Owner attribution must use Buzz's relay-trusted actor tag, leaving exactly one p tag for the assignee.
  • P1 — templated routing fields cannot be saved, and resolved task IDs are not checked. The executor resolves templates in channel and task_id, but schema validation rejects those same templates. The executor test bypasses WorkflowDef::validate and uses a non-UUID event ID as a task ID, masking the mismatch. A resolved task_id can consequently reach the sink without the UUID guarantee documented by the action.
  • P2 — identity shape checks accept values the runtime rejects. Both the pubkey and single-template validators trim only for validation, then retain the padded original value. Inputs such as " <64-hex>" and " {{trigger.author}}" save successfully and fail later. The template check also accepts malformed nested/triple-brace forms.
  • P2 — the public sink boundary trusts malformed correlation IDs. A direct ActionSink::assign_agent caller can emit a non-UUID task tag (and the submitted implementation silently drops an empty one), despite the public contract claiming a UUID. The sink needs its own validation/canonicalization.
  • P2 — action failures are reported as webhook_failed. ActionSinkError maps to WorkflowError::WebhookError, so a removed assignee or invalid assignment is persisted with the wrong stable error code. Database failures also need to retain their database classification.
  • P2 — assignment text is copied into an info log. Task content may contain sensitive incident/customer data; the new action should log routing metadata without duplicating the full message body.
  • P3 — diagnostics and docs describe the wrong action/contract. Shared channel resolution reports SendMessage for AssignAgent; docs claim exactly two p tags even though the implementation deduplicates owner=assignee; and the parse_all_action_types coverage omits the new variant.
  • P3 — PR metadata violates this repository's attribution rule. The Generated with Claude Code footer must be removed. I attempted to remove only that footer, but the reviewer account cannot edit another author's PR description.

I prepared the complete fix as 626849e7 on Complear:review/pr-6060-fix. It uses actor for attribution and a sole assignee p tag, aligns definition/runtime validation for all templated identity fields, validates UUIDs at both executor and sink boundaries, preserves action/database error classifications, removes message content from the new info log, and adds regression coverage.

Verification on the fix:

  • cargo test -p buzz-workflow --lib: 173 passed, 2 Postgres-gated ignored
  • cargo test -p buzz-relay --lib workflow_sink: 18 passed, 3 Postgres-gated ignored
  • cargo test -p buzz-relay --lib workflow_sink -- --ignored --test-threads=1 against an isolated fully migrated database: 3 passed
  • strict clippy for buzz-workflow + buzz-relay: passed
  • cargo doc -p buzz-workflow --no-deps: passed
  • full relay library suite: 878 passed; its two unrelated global-state/timing failures both passed on exact isolated rerun
  • formatting and git diff --check: passed

parse_all_action_types exists to catch exactly this and was passing green
while blind to the new variant: it asserted steps.len() == 7 over a fixture
that never included assign_agent, so an 8th action type could ship without
the test noticing. Extended the fixture to 8 steps, bumped the assertion, and
added the matches! arm.

ARCHITECTURE.md still read '7 action types' above a 7-row table. Added the
row and corrected the count.

Both are the same pattern: a new entry in a closed enumeration that lives in
several places the compiler does not check. 'git grep -ln <new-variant>'
should return more than the files you edited.

Verified: cargo test -p buzz-workflow 169 passed / 0 failed; cargo fmt clean.

Co-authored-by: Michael Feth <michael@jira-flow.com>
Signed-off-by: Michael Feth <michael@jira-flow.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants